上一章中,我们对预缓存进行了讲解,基于该机制,当一个页面被访问时,脚本、样式、图片等资源可直接从缓存中获取,这在很大程度上加速了页面的渲染。但如果仅止步于此,那么当用户处于电梯、高铁、地铁等网络极其恶劣的环境下,用户可能看到的依旧是空白页(如下所示):

  • 这种无任何反馈的空白页可以说是用户体验的终极杀手,或许就在此刻,用户关闭了站点,转而投向了竞争对手的怀抱……
  • 那么如何避免此种悲剧的发生呢?如果仔细观察页面构造,我们会发现页面中变化的始终是部分内容,比如:

上图所展示的页面主要由 Header、列表及添加按钮三部分组成,其中除了列表,其余部分的内容均是不变的。基于此并结合预缓存,我们可以通过以下方式来解决空白页的问题:

  • 通过缓存获取 Header、添加按钮等静态信息。
  • 通过网络获取列表等动态信息。
  • 将前两步得到的信息拼装成完整的 HTML 返回给浏览器进行渲染。

以上步骤所展示的解决方案正是本章将要讲解的应用 Shell 架构,在本章的剩余部分,我们将通过以下几个方面对该架构的实施进行详细说明:

  • Shell 文件生成。
  • fetch 事件。
  • 服务端实现。

# Shell 文件生成

运行示例中yarn build 命令,public/shell 目录下会生成以下文件:

├── home_top.html
├── home_bottom.html
├── ....

打开 home_top.html 与 home_bottom.html,我们会发现这两个文件分别为一个完整 HTML 文档的一部分:

home_top.html:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content= "width=device-width, user-scalable=no">
    <link rel="manifest" href="/manifest.json">
    <title>PWA 博文</title>
    <link href="/global.4735f484d20bd330417c.css" rel="stylesheet"><link href="/home.5704e93d911a9fcdaf14.css" rel="stylesheet">
  </head>
  <body>
    <header class="header">
      <div class="title">PWA 博文</div>
      <div class="action action-install-app">安装应用</div>
      <div class="action action-unsubscribe">取消订阅</div>
    </header>
    <section class="container">

html_bottom.html:

  </section>
    <img class="side-action" src="/plus.6b433cf1453965994b3029ea10ec8449.png" />
    <script type="text/javascript" src="/db.90cab081eccbdfa6e090fc6ebbadb90f.js"></script>
    <script type="text/javascript" src="/network.c91f3df5f50e951c4317d298a52c9dd0.js"></script>
    <script type="text/javascript" src="/global.4735f484d20bd330417c.js"></script>
    <script type="text/javascript" src="/home.5704e93d911a9fcdaf14.js"></script>
  </body>
</html>

由于动态的列表信息位于 <section class="container"></section> 中,所以我们以 <section class="container"> 为标志将完整的 home.html 分解为:

  • home_top.html
  • 从服务端获取的用以填充 <section class="container"></section> 的动态 HTML
  • html_bottom.html

通过这样的拆分,当我们请求该页面时,由于 Service Worker 的预缓存已经缓存了 home_top.html 和 html_bottom.html,这个时候即使因网络异常而无法获取动态的列表信息,我们依旧可以将 home_top.html 及 html_bottom.html 拼装成的 HTML 返回给浏览器进行渲染,从而避免空白页给用户带来的不适。

  • 上述 home_top.html 及 html_bottom.html 等 HTML 片段文件我们称之为 Shell 文件,我们完全可以自行创建并完成 Shell 文件的编写,但这种方式相当繁琐,因此本节的剩余部分我们将讨论如何通过 webpack 动态生成 Shell 文件。

首先我们需要定义一个模板文件,比如 index.ejs

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content= "width=device-width, user-scalable=no">
    <link rel="manifest" href="/manifest.json">
    <title>PWA 博文</title>
  </head>
  <body>
    <header class="header">
      <div class="title">PWA 博文</div>
      <% if (isEnableGoHomeLink) { %>
        <a class="action action-to-home" href="/">首页</a>
      <% } %>
      <div class="action action-install-app">安装应用</div>
      <div class="action action-unsubscribe">取消订阅</div>
    </header>
    <section class="container">
      <!-- shell -->
    </section>
    <% if (isShowPlusAction) { %>
      <img class="side-action" src="<%= require('file-loader?name=[name].[hash].[ext]!./home/plus.png') %>" />
    <% } else if (isShowEditAction) { %>
      <img class="side-action" src="<%= require('file-loader?name=[name].[hash].[ext]!./detail/edit.png') %>" />
    <% } %>
    <script type="text/javascript" src="<%= require('file-loader?name=[name].[hash].[ext]!./db.js') %>"></script>
    <script type="text/javascript" src="<%= require('file-loader?name=[name].[hash].[ext]!./network.js') %>"></script>
  </body>
</html>

该模板的内容为一个完整的 HTML 文档,注意我们在 <section class="container"></section> 中间添加的 <!-- shell --> 注释,该注释用于告知 webpack 以此为标志进行 Shell 文件的拆分。

接下来,我们需要创建一个用于生成 Shell 文件的 webpack plugin,比如 ShellPlugin.js

const path = require('path');
const fs = require('fs-extra');
const editor = require("mem-fs-editor");

class ShellPlugin {
  constructor() {
    this.htmls = [];
  }

  apply(compiler) {
    compiler.hooks.compilation.tap('ShellPlugin',  compilation => {
      compilation.hooks.htmlWebpackPluginAfterHtmlProcessing.tapAsync('ShellPlugin', (data, callback) => {
        this.htmls.push({
          key: data.outputName.replace(/\.html$/i, ''),
          html: data.html
        });
        callback(null, data);
      });
    });
    compiler.hooks.emit.tapAsync('ShellPlugin', async (compilation, callback) => {
      const shellRootPath = path.join(__dirname, '../../public/shell');
      await fs.ensureDir(shellRootPath);
      for (const htmlConfig of this.htmls) {
        const { key, html } = htmlConfig;
        const htmlParts = html.split('<!-- shell -->').map(part => part.trim());
        await fs.writeFile(
          path.join(shellRootPath, `${key}_top.html`),
          htmlParts[0],
          'utf-8'
        );
        await fs.writeFile(
          path.join(shellRootPath, `${key}_bottom.html`),
          htmlParts[1],
          'utf-8'
        );
        compilation.assets[`shell/${key}_top.html`] = {
          source: () => htmlParts[0],
          size: () => htmlParts[0].length
        };
        compilation.assets[`shell/${key}_bottom.html`] = {
          source: () => htmlParts[1],
          size: () => htmlParts[1].length
        };
        delete(compilation.assets[`${key}.html`]);
      }
      callback();
    });
  }
}

module.exports = ShellPlugin;

在 apply 方法中:

首先在 webpack compiler 的 compilation 钩子中通过 html-webpack-pluginhtmlWebpackPluginAfterHtmlProcessing 钩子来获取将要生成的 HTML 文件信息:

this.htmls.push({
  key: data.outputName.replace(/\.html$/i, ''),
  html: data.html
});

然后在 webpack compileremit 钩子中遍历第一步得到的 html 信息,根据标志<!-- shell --> 将每一项拆分成 top、bottom 两个 Shell 文件:

const { key, html } = htmlConfig;
const htmlParts = html.split('<!-- shell -->').map(part => part.trim());
await fs.writeFile(
  path.join(shellRootPath, `${key}_top.html`),
  htmlParts[0],
  'utf-8'
);
await fs.writeFile(
  path.join(shellRootPath, `${key}_bottom.html`),
  htmlParts[1],
  'utf-8'
);

将每一项生成的 Shell 文件添加到 compilationassets 列表中,这样方便后续执行 SWFilePlugin 时将这些 Shell 文件添加到预缓存列表中去:

compilation.assets[`shell/${key}_top.html`] = {
  source: () => htmlParts[0],
  size: () => htmlParts[0].length
};
compilation.assets[`shell/${key}_bottom.html`] = {
  source: () => htmlParts[1],
  size: () => htmlParts[1].length
};

由于不需要 html-webpack-plugin 生成的 html 文件,所以我们需要将其从 compilation 的 assets 列表中移除:

delete(compilation.assets[`${key}.html`]);

最后我们需要如下修改 webpack.config.js

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { ShellPlugin, SWFilePlugin } = require('./webpack/plugins');

const pageConfigs = [
  { key: 'home', isEnableGoHomeLink: false, isShowEditAction: false, isShowPlusAction: true },
  { key: 'detail', isEnableGoHomeLink: true, isShowEditAction: true, isShowPlusAction: false },
  { key: 'edit', isEnableGoHomeLink: true, isShowEditAction: false, isShowPlusAction: false },
].reduce((result, { key, isEnableGoHomeLink, isShowEditAction, isShowPlusAction }) => {
  result.entry[key] = `./client/${key}/index.js`;
  result.html.push(new HtmlWebpackPlugin({
    filename: `${key}.html`,
    template: './client/index.ejs',
    chunks: [key, 'global'],
    templateParameters: {
      isEnableGoHomeLink,
      isShowPlusAction,
      isShowEditAction
    }
  }));
  return result;
}, { entry: {}, html: [] });

module.exports = {
  //... 其他配置
  plugins: [
    //... 其他插件
    ...pageConfigs.html,
    new ShellPlugin(),
    //... 其他插件
    new SWFilePlugin()
  ]
};

# fetch 事件

上一节中,我们讨论了如何利用 webpack 自动生成页面的 Shell 文件,本节我们将继续讨论如何利用这些 Shell 来优化页面的渲染。

我们可以通过监听 Service Worker 的 fetch 事件来拦截网络请求,因此需要在该事件中对页面请求做出处理,其主要逻辑如下:

async function getCache(cacheName, cacheKey) {
  const cache = await caches.open(cacheName);
  return await cache.match(cacheKey);
}

self.addEventListener('fetch', event => {
  const { request } = event;
  if (request.method.toLowerCase() === 'get') {
    event.respondWith((async () => {
      const cacheKey = new URL(request.url, location).pathname;
      //...其他类型请求处理逻辑
      return fetchPage(cacheKey);
    })());
  }
});

fetch 事件中,我们通过拦截 get 请求并在一系列请求类型判断后,调用了 fetchPage 方法来响应页面请求,该方法的主要逻辑如下:

function fetchPage(cacheKey) {
  let shellType;
  if (cacheKey === '/') {
    shellType = 'home';
  } else if (/^\/create|\/edit\/\d+$/.test(cacheKey)) {
    shellType = 'edit';
  } else if (/^\/detail\/\d+$/.test(cacheKey)) {
    shellType = 'detail';
  }

  const stream = new ReadableStream({
    start(controller) {
      function pushStream(stream) {
        const reader = stream.getReader();
        function read() {
          return reader.read().then(result => {
            if (result.done) {
              return;
            }
            controller.enqueue(result.value);
            return read();
          });
        }
        return read();
      }

      (async () => {
        const top = await getCache(precacheName, `/shell/${shellType}_top.html`);
        await pushStream(top.body);
        const context = await fetch(cacheKey, {
          headers: {
            'only_content': 1
          }
        });
        if (content) {
          await pushStream(content.body);
        } else {
          const errorContent = new Response(
            '<div class="message">网络错误</div>',
            { headers: { 'Content-Type': 'text/html' } }
          );
          await pushStream(errorContent.body);
        }
        const bottom = await getCache(precacheName, `/shell/${shellType}_bottom.html`);
        await pushStream(bottom.body);
        controller.close();
      })();
    }
  });

  return new Response(stream, {
    headers: { 'Content-Type': 'text/html' }
  });
}

该方法首先通过 cacheKey 获得页面所属的 Shell 类型,然后通过 ReadableStream 实例来逐步获取页面中每个部分的响应信息,其中:

Shell 文件 top 和 bottom 通过预缓存获取:

const top = await getCache(precacheName, `/shell/${shellType}_top.html`);
//...其他逻辑
const bottom = await getCache(precacheName, `/shell/${shellType}_bottom.html`);
//...其他逻辑

页面中的正文信息通过网络获取:

const context = await fetch(cacheKey, {
  headers: {
    'only_content': 1
  }
});
//...其他逻辑

需要注意的是,在请求正文信息时我们需要添加头信息 'only_content': 1 来告知服务端只返回正文信息。

最后,我们用生成的 ReadableStream 实例 stream 作为 Response 参数来实例化一个 Response 对象并返回。

方法 fetchPage 的逻辑非常直观,我想唯一能让大家产生疑问的是:为什么使用 ReadableStream 而非 Promise。这是因为如果使用 Promise,我们需要等到 top shell、正文信息、bottom shell 全部 resolve 后才能实例化 Response 对象;如果使用 ReadableStream,在获取部分信息后,可通过 controller.enqueue 方法将其加入队列,这样浏览器便可对已入队列的信息进行渲染,而无需等待所有信息准备完毕。当然,ReadableStream 的浏览器兼容情况不如 Promise,鉴于此大家可以思考下如何实现 fetchPage 方法的兼容,此处不再阐述。

# 服务端实现

服务端需要做的就是根据上文中提到的头信息 only_content 来决定是否只返回正文部分信息,这是因为在 Service Worker 尚未取得页面控制权时依旧能够正常的显示页面。主要实现如下

//... 其他引用
const router = new Router();

async function renderPage(ctx, type, content) {
  if (parseInt(ctx.request.headers['only_content'], 10) === 1) {
    ctx.body = content;
  } else {
    const rootPath = path.join(__dirname, '../public/shell');
    const top = await fs.readFile(path.join(rootPath, `${type}_top.html`), 'utf-8');
    const bottom = await fs.readFile(path.join(rootPath, `${type}_bottom.html`), 'utf-8');
    ctx.body = `${top}${content}${bottom}`;
  }
}

router.get('/', async ctx => {
  const articles = await db.getArticles();
  let content = '<div class="message">暂无任何数据</div>';
  if (Array.isArray(articles) && articles.length > 0) {
    content = articles.reduce((result, item) => {
      result += `<div class="item" onclick="onListItemClicked(${item.id})">
        <div class="title">${item.title}</div>
        <div class="content">${item.content}</div>
        <div class="times">
          <div>首发于:${item.created_at}</div>
          <div>更新于:${item.created_at}</div>
        </div>
      </div>`;
      return result;
    }, '<div class="list">') + '</div>';
  }
  await renderPage(ctx, 'home', content);
});

//... 其他逻辑
  • router.get('/', ...)的回调中,我们首先构造出正文部分的 html 内容 content,而后将其作为参数传递给 renderPage 方法。
  • renderPage 方法中,如果头信息 only_content 的值为 1,我们就将 content 直接返回,否则根据其传递的 shell 类型(此处为 home)来得到 top shellbottom shell 的内容,并将其与 content 合并后返回。

# 总结

上文中,我们对 应用 Shell 的原理及其实施过程进行了详细说明,它为解决恶劣网络环境下,服务端无法响应或响应缓慢时出现的异常或空白页提供了很好的解决方案。可是,页面请求是否还有别的优化措施呢?当然有,这便是我们下一章将要学习的导航预加载。

阅读全文